Skip to content

feat(surveys): re-translate displayed survey on language change#686

Open
bs1180 wants to merge 12 commits into
mainfrom
posthog-code/survey-live-language-update
Open

feat(surveys): re-translate displayed survey on language change#686
bs1180 wants to merge 12 commits into
mainfrom
posthog-code/survey-live-language-update

Conversation

@bs1180

@bs1180 bs1180 commented Jun 30, 2026

Copy link
Copy Markdown

💡 Motivation and Context

Once a translated survey was displayed, later changes to the user's language (e.g. via setPersonProperties / identify with a language property) had no effect — the survey kept the language it was first rendered with. The translation was resolved only at display time.

This makes a survey already on screen follow the user's language: when the person properties used for flags change and a matching translation exists, the survey re-translates in place, keeping the current question and any progress.

Closes #66998

💚 How did you test it?

Added unit tests (run on the iOS simulator, where survey code compiles):

  • Changing the language person property while a survey is active pushes a re-translated survey to the delegate and updates the tracked survey language.
  • Re-resolving to the same language is a no-op (no churn of $survey_language or re-renders).
  • No active survey ⇒ no update pushed.
  • SurveyDisplayController.updateSurvey preserves the current question index and completion state, and ignores updates for a different / absent survey.

I was unable to run make test / the iOS build in this environment (no Xcode available), so CI should be relied on for the full build + simulator test run.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Implemented from a Slack thread request, logged as #66998. Built with the PostHog Slack app (Claude Code). Key decisions:

  • Trigger: chose an internal NotificationCenter signal posted from PostHogRemoteConfig.setPersonPropertiesForFlags (the single chokepoint all person-property updates flow through) rather than sniffing the captured event stream — the $set/$identify ordering relative to the flag-property update is inconsistent, so the notification is the order-correct, robust hook.
  • UI update: added an optional updateSurvey(_:) to the delegate and made SurveySheet observe the display controller (instead of the snapshot passed by .sheet(item:)), so an in-place content swap with the same survey id renders live while preserving SwiftUI @State (current question, typed answers).
  • Guardrails: only commit + render when the matched language actually changes; the whole path is a no-op when no survey is displayed.

One known limitation worth a reviewer's eye: for choice questions, selection is keyed by the option's string value, so a mid-survey language switch can drop an in-progress selection on those question types (labels change). Text/rating answers are preserved.


Created with PostHog from a Slack thread

Once a survey was on screen, later updates to the user's `language` person
property had no effect — the translation was resolved only at display time. Now
the SDK re-resolves the survey language whenever the person properties used for
flags change and updates the on-screen survey in place, preserving the current
question and progress.

- Post an internal notification when person properties for flags change.
- Survey integration observes it, re-resolves translations for the active
  survey, and pushes an in-place update only when the matched language actually
  changed (so `$survey_language` and renders aren't churned needlessly).
- Add optional `updateSurvey(_:)` to `PostHogSurveysDelegate`; the default
  delegate updates the live survey via the display controller. The sheet now
  observes the controller so re-translations render without restarting.

Closes #66998

Generated-By: PostHog Code
Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
@bs1180 bs1180 self-assigned this Jun 30, 2026
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. PostHog/Surveys/PostHogSurveysDefaultDelegate.swift, line 120-123 (link)

    P1 Translation update dropped when survey has a display delay

    When updateSurvey is called while a survey is still within its surveyPopupDelaySeconds window, it correctly updates self.pendingSurvey to the re-translated copy. However, the DispatchWorkItem closure captures the original survey local variable at the time it is created, so when the delay fires it always calls displayController.showSurvey(survey) with the pre-update content. The work item should use self.pendingSurvey — the most-recent version — at the point it executes.

Reviews (1): Last reviewed commit: "feat(surveys): re-translate displayed su..." | Re-trigger Greptile

Comment thread PostHog/Surveys/SurveyDisplayController.swift
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-07-17 07:48:13 UTC
Duration: 229593ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2985ms
Format Validation.Event Has Uuid 509ms
Format Validation.Event Has Lib Properties 2866ms
Format Validation.Distinct Id Is String 2806ms
Format Validation.Token Is Present 408ms
Format Validation.Custom Properties Preserved 2647ms
Format Validation.Event Has Timestamp 2709ms
Retry Behavior.Retries On 503 11819ms
Retry Behavior.Does Not Retry On 400 4716ms
Retry Behavior.Does Not Retry On 401 4770ms
Retry Behavior.Respects Retry After Header 8039ms
Retry Behavior.Implements Backoff 21752ms
Retry Behavior.Retries On 500 9309ms
Retry Behavior.Retries On 502 9243ms
Retry Behavior.Retries On 504 9183ms
Retry Behavior.Max Retries Respected 21727ms
Deduplication.Generates Unique Uuids 3000ms
Deduplication.Preserves Uuid On Retry 9112ms
Deduplication.Preserves Uuid And Timestamp On Retry 16273ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7761ms
Deduplication.No Duplicate Events In Batch 2926ms
Deduplication.Different Events Have Different Uuids 846ms
Compression.Sends Gzip When Enabled 2931ms
Batch Format.Uses Proper Batch Structure 2831ms
Batch Format.Flush With No Events Sends Nothing 332ms
Batch Format.Multiple Events Batched Together 2962ms
Error Handling.Does Not Retry On 403 4827ms
Error Handling.Does Not Retry On 413 4753ms
Error Handling.Retries On 408 9255ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2922ms
Request Payload.Flags Request Uses V2 Query Param 2789ms
Request Payload.Flags Request Hits Flags Path Not Decide 2698ms
Request Payload.Flags Request Omits Authorization Header 2752ms
Request Payload.Token In Flags Body Matches Init 2751ms
Request Payload.Groups Round Trip 2838ms
Request Payload.Groups Default To Empty Object 2802ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2828ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2864ms
Request Payload.Disable Geoip Omitted Defaults To False 2796ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2881ms
Request Lifecycle.No Flags Request On Init Alone 79ms
Request Lifecycle.No Flags Request On Normal Capture 2786ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5501ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2748ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2996ms

bs1180 added 2 commits June 30, 2026 14:22
- Keep PostHogSurveyIntegration.swift under the 1200-line SwiftLint limit by
  moving self-contained helper types (survey state, condition matching, rating
  buckets) into a new PostHogSurveyMatching.swift, registered in the Xcode
  project.
- Use the non-availability-gated `_surveysConfig` backing in tests; the public
  `surveysConfig` accessor is iOS 15+ only and broke the simulator build.
- Add the new optional `updateSurvey(_:)` delegate method to the public API
  snapshot.

Generated-By: PostHog Code
Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
Address Greptile review on #686:

- When a survey is within its display-delay window and its language updates,
  `updateSurvey` refreshes `pendingSurvey`, but the scheduled work item showed
  the captured (pre-update) copy. Read the latest `pendingSurvey` at execution
  time so the re-translated content is shown.
- Distinguish the "no survey displayed" vs "different survey" cases in the
  display controller's update log to make debugging clearer.

Generated-By: PostHog Code
Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
@turnipdabeets
turnipdabeets requested a review from a team July 2, 2026 20:54
@turnipdabeets

Copy link
Copy Markdown
Contributor

@lucasheriques I know you worked a bit on this, mind giving this a review?

@turnipdabeets
turnipdabeets marked this pull request as ready for review July 2, 2026 20:56
@turnipdabeets
turnipdabeets requested a review from a team as a code owner July 2, 2026 20:56
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "fix(surveys): apply latest translation w..." | Re-trigger Greptile

@turnipdabeets

Copy link
Copy Markdown
Contributor

Thanks @bs1180 instead of a CHANGELOG we actually need a changes, please see RELEASING.md

@ioannisj

ioannisj commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@bs1180 friendly ping on this one

Follow the repo's Changesets release flow: add a minor changeset describing the live survey re-translation feature and revert the direct CHANGELOG.md edit (the release workflow regenerates CHANGELOG.md from changesets).

Generated-By: PostHog Code
Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
@bs1180

bs1180 commented Jul 6, 2026

Copy link
Copy Markdown
Author

Hey @turnipdabeets and @ioannisj, sorry for the delay. This PR is the product of letting PostHog AI loose on a customer complaint :D I'm not an IOS dev so not sure if this implementation makes sense and I've done literally zero QA. Is that OK with you? Won't be offended this just gets rejected.

@ioannisj

ioannisj commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@bs1180 thank you, I can do the QA part and see also how this will affect Flutter surveys as well. we'll probably need to land a similar feature for posthog-android as well I imagine? To get some more context though, do you have the original link to the customer complaint? I personally don't think that on-the-fly language changes are the norm so I'm not completely sure we should be supporting this

@ioannisj

ioannisj commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Would like to know @turnipdabeets thoughts as well on this, maybe it's just me :)

@bs1180

bs1180 commented Jul 7, 2026

Copy link
Copy Markdown
Author

Yup totally understood - PostHog/posthog#66998 is the original feature request, and this is the customer Slack thread

@turnipdabeets

Copy link
Copy Markdown
Contributor

Would like to know @turnipdabeets thoughts as well on this, maybe it's just me :)

I think we need on-device QA, if you want to take that @ioannisj. Maybe we need to check out the sheet dismissal animation too. Not sure if we need @PostHog/team-surveys on par before making this change? And it makes sense to do this for the other mobile if it's needed, android flutter and RN.

@ioannisj ioannisj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LG. Tested this on iOS and Flutter and left some minor comments. We'll probably want to follow this up on Android and Flutter side

Comment thread PostHog/PostHogRemoteConfig.swift Outdated
Comment thread PostHog/Surveys/PostHogSurveyIntegration.swift
Comment thread PostHog/Surveys/Utils/PostHogSurveyMatching.swift
@ioannisj

Copy link
Copy Markdown
Contributor

Hey @bs1180, want me to take this over if you've moved on to something else?

@bs1180

bs1180 commented Jul 16, 2026

Copy link
Copy Markdown
Author

Hey @ioannisj, sorry didn't see your latest comments. Yes please - as warned I'm not a mobile dev so my next steps would be to just keep prompting in Posthog Code :D

Thank you!

displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .multipleChoice(resp))
}
} else {
SingleChoiceQuestionView(question: currentQuestion) { resp in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR note says a language switch can drop an in-progress choice selection, but it's a bit sharper than that: selectedChoices in the choice views is @State Set<String> keyed by label text and survives the in-place swap — so after a switch the highlight vanishes but canSubmit stays true, and submitting records the stale old-language label. For the open choice, response == openChoice compares the old label against the new-language open choice, fails, and submits the label instead of the typed text. Could we key selection by choice index, or clear/remap it in .onChange(of: question.choices)?

let onNextQuestionClicked: (_ index: Int, _ response: PostHogSurveyResponse) -> Void
// Observed so the sheet re-renders when the displayed survey is updated in place
// (e.g. re-translated after a language change), not just when questions advance.
@ObservedObject var displayManager: SurveyDisplayController

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Now that the sheet observes the controller live, dismissSurvey()'s synchronous reset of currentQuestionIndex/isSurveyCompleted re-renders question 1 of fallbackSurvey during the slide-out (pre-PR, the let snapshots kept the outgoing frame frozen). Since showSurvey re-initializes both anyway, could we drop those two resets from dismissSurvey()?

class TestLiveTranslationUpdate {
let server: MockPostHogServer

init() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] postHog.close()/reset() at the tail of each test body get skipped when a #require throws (there's one in getSurveyIntegration on every path), leaking a live SDK and its subscription into the next .serialized test. The suites in PostHogSurveysTest.swift build the SDK in init() and tear down in deinit — could we do the same here?


guard let displaySurvey else { return }

DispatchQueue.main.async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The lock comment above guarantees commit order, but this enqueue happens after the lock is released — two racing refreshes can commit fr→de yet enqueue de→fr, leaving the screen on fr while activeSurveyLanguage (and $survey_language) say de. Moving the DispatchQueue.main.async inside the withLock block makes enqueue order match commit order (async enqueue doesn't block).

let translations = resolveSurveyTranslations(survey: activeSurvey, targetLanguage: language)

// Commit only if the update targets a different language than what's currently shown
guard self.activeSurveyLanguage != translations.matchedKey else { return nil }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] If a refresh lands in the window after setActiveSurvey but before the initial renderSurvey reaches the controller, the language commits here but the dispatched update is dropped (displayedSurvey is nil, no pending survey) — and the next refresh no-ops since the language already matches. Tiny window, sticky outcome. Worth reconciling in handleSurveyShown when the shown snapshot's language differs from activeSurveyLanguage?

/// keeps the language it was first rendered with.
///
/// - Parameter survey: The survey with refreshed (e.g. re-translated) content. Its `id`
/// matches the survey passed to `renderSurvey`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Two contract points a custom-delegate author needs here: this is always delivered on the main thread, and it can arrive before the survey is visibly shown (e.g. during surveyPopupDelaySeconds — that's why the default delegate refreshes pendingSurvey). A delegate with its own display delay will silently drop the update otherwise. Could we add both to the doc comment?

// Notify subscribers (e.g. surveys) so a survey already on screen can re-resolve its
// language if the user's `language` property changed. Invoked outside the lock so
// subscribers don't run while we hold it.
onPersonPropertiesForFlagsChanged.invoke(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This fires even when the merge changed nothing, so every capture() carrying userProperties re-runs translation resolution under activeSurveyLock while a survey is up. Skipping the invoke when no merged key changed value would keep it off hot paths.

// Commit only if the update targets a different language than what's currently shown
guard self.activeSurveyLanguage != translations.matchedKey else { return nil }
self.activeSurveyLanguage = translations.matchedKey
self.activeSurveyQuestionTranslations = translations.questions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] Once this commits mid-survey, survey sent stamps all $survey_questions (and $survey_language) with the final translations — answers given before the switch are reported with question text the user never saw, and survey shown vs sent disagree on the language. Acceptable for analytics, or should we freeze the language after the first response?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants